iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 16
0

相依注入 - Dependency Injection ,對於 OOP 來說是一個很重要的概念,各框架、語言也都有非常多的函式庫可以用。主流的注入的方法主要有兩種:setter, constructor ,但這兩種方法都是必須要有“Object”才能夠達成,對於一個不會有“Object”的 functional programming 來說,要怎麼做到 Dependency Injection 呢?今天就來介紹 functional programming 界的 Dependency Injection - Reader Monad。

看到這邊讀者可能有疑問,之前介紹的 Algebraic Data Type 不是使用 class 做的嗎?在 Kotlin 中產生一個 class 的實例不就是是一個“Object”嗎?其實在 Functional Programming 的世界,這個 class 比較像是被當成 "Struct" 來使用,Algebraic Data Type 不會有任何的 function ,沒有任何行為,就只是一個資料。

Injection

如果要在 function 中注入實例,最直覺的想法當然是從參數注入了:

class Point(val x: Int, val y: Int)

val add: (Point, Point) -> Point = { first, second ->
    Point(first.x + second.x, first.y + second.y)
}

那如果現在注入的是一個複雜的類別呢?下面是一個 Github 的應用程式的範例。

// 1 
interface GithubRepository {
    fun login(email: String): Either<Throwable, String>
    ...
    fun getRepos(userId: String): List<Repo>
    fun getIssues(repoId: String): List<Issue>
    ...
}

// 2 
interface GithubService {
    fun searchIssue(userId: String, keyword: String, githubRepository: GithubRepository): List<Issue>
    fun login(email: String, githubRepository: GithubRepository): Maybe<User>
    fun addComment(issueId: String, comment: String, githubRepository: GithubRepository): Boolean
}

//3 
class User(val userId: String)
class Repo(val repoId: String, val title: String)
class Issue(val issueId: String, val content: String) 
  1. GithubRepository 提供了各式各樣的 github API 操作,這些 API 都是最基本的操作,不會有商業邏輯在這個類別中。
  2. GithubService 包含了各種商業邏輯的操作,例如 searchIssue 就可能需要打 GithubRepository 的兩到三個 API ,再經過一連串的計算,最後返回結果。為了跟 GithubRepository 做互動,所以必須將實例以 setter 的方式做注入,同時這麼做也是為了讓這些 function 都是 Pure function
  3. 上面這兩個類別需要用的的領域資料結構,今天這只是個簡單的範例,所以這些資料結構跟都以簡單能夠示範為主,實際上會再更複雜一些。

Requirement

假設現在我要做一個機器人,想要對某些符合條件的 Issue 都加上 Comment ,那麼實作可能會像下面這樣:

fun robot(githubService: GithubService, githubRepository: GithubRepository) {
    val keyword = "master"
    val comment = "No more master/slave"

    // 1
    githubService.login("yanbin@hotmail.com", githubRepository)
        // 2
        .map { user -> githubService.searchIssue(user.userId, keyword, githubRepository) }
        .map { issues: List<Issue> ->
            issues.map { issue ->
                // 3
                githubService.addComment(issue.issueId, comment, githubRepository)
            }
        }
        // 4
        .fold(someFun = {
            // Do something
        }, noneFun = {
            // Do something for user not find
        })
}
  1. 為了拿到某個使用者的 Id ,就要藉由呼叫 githubService.login() 來做登入,由於這個 function 回傳的是一個 Maybe ,有可能結果是空的,為了對有值的情況下做操作,就要使用 map 這個 operator。
  2. 接續上一個結果,我們預期會拿到 User 這個資料結構,接下來就可以利用該資料的 userId 來做下一步的操作,使用關鍵字來搜尋該使用者的 Issue,這裡一樣會用到 GithubService 與 GithubRepository 。
  3. 這裡出現了兩層的 map ,別慌張!第一層是對 Maybe 做操作,第二層是對 List 做操作,關注第二層即可,上一個結果回傳了 List ,這些是我們要搜尋的結果,最後就可以針對這些 Issue 一個一個加上 comment,就完成我們的需求了。最後這裡還是出現了 GithubService 與 GithubRepository 。
  4. Maybe 的收尾,依照情況做不同的處理,可能是顯示對話框,也可能什麼都不做。

Reader

上面的程式碼中, GithubService 與 GithubRepository 各出現了三次,其實寫久了會有點煩,會想要用更簡潔的方式來處理,而且為了要執行 robot ,還必須要有 GithubService 與 GithubRepository 的實例才行,既然從參數注入這個方法這麼麻煩,現在我們來換另一種做法:

interface GithubService {
    // 為了簡化問題把 Maybe 拿掉了,關於這部分之後有機會會在回過頭來做給大家看
    fun login(email: String): (GithubRepository) -> User
    // 一樣為了簡化問題把 List 拿掉了,關於這部分在章節的最後會回過頭來做給大家看
    fun searchIssue(userId: String, keyword: String): (GithubRepository) -> Issue
    fun addComment(issueId: String, comment: String): (GithubRepository) -> Boolean
}

請看每個 function 的回傳型別,居然把型別往後移了!?還串起來變成了一個 function type ,這是什麼概念?其實這就是柯里化(curried),目前可以先不用在意柯里化是什麼,這概念在後面的章節會再詳細介紹。目前要在意的是,這個 function type 給予了我們一個額外的能力,一個不需要有 GithubRepository 實例的能力。這裡運用 lazy execution 的概念,在執行上述 GithubService 的任何 function 時,他們回傳的是一個 lambda function ( 型別為 (GithunRepository) -> T ),而這個 lambda function 可以再傳給一個 higher order function,之後就可以在任意的時間點執行這個 function,也可以在任意的時間點再注入 GithubRepository。

接下來,我們還希望這些流程還是可以很清楚的表現在程式碼上面,我們的最終目的是一個清晰,好讀的程式碼:

githubService.login(email)
        .???{ user -> githubService.searchIssue(user.userId, keyword)}
        .???{ issue -> githubService.addComment(issue.issueId, comment)}

上面的這個流程是順的,看起來非常舒服,也不用看到 GithubRepository 了,但是這件事做得到嗎?我們有辦法組合這些 function 嗎?於是接下來就要輪到 Reader 出場了

class Reader<D, out A>(val run: (D) -> A) {

    inline fun <B> map(crossinline fa: (A) -> B): Reader<D, B> = Reader {
        d -> fa(run(d))
    }

    inline fun <B> flatMap(crossinline fa: (A) -> Reader<D, B>): Reader<D, B> = Reader {
        d -> fa(run(d)).run(d)
    }
}

Reader 的建構子是一個 function ,一個 input D 配上一個 output A ,D 對應到的就是需要的 Dependency , A 對應到的是目前正在使用的 Type 。這些剛好都可以取代上面的 (GithubRepository) -> User 為 Reader<GithubRepository, User> ,(GithubRepository) -> Issue 為 Reader<GithubRepository, Issue> 與 (GithubRepository) -> Boolean 為 Reader<GithubRepository, Boolean> 。

interface GithubService {
    fun login(email: String): Reader<GithubRepository, User>
    fun searchIssue(userId: String, keyword: String): Reader<GithubRepository, List<Issue>>
    fun addComment(issueId: String, comment: String): Reader<GithubRepository, Boolean>
}

接下來再來看看 map 做的是什麼事, map 建立了一個新的 Reader , 裡面做的事情是 fa 這個 function 與 run - 建構子傳進來的參數來做組合,所以 function 組合的結果會是一個 Type B 的變數,於是就會得到一個 Reader<D, B> 的結果。寫的更加直覺一點就是 Reader<D, A> map (A -> B) => Reader<D, B> ,那這不是跟 List, Maybe 還有 Either 是一樣的模式嗎? 所以一樣的,也會有一個 flatMap 是 Reader<D, A> flatMap (A -> Reader<D, B>) => Reader<D, B> 。

好了,在回過頭來看看我們希望的樣子吧:

githubService.login(email)
        .???{ user -> githubService.searchIssue(user.userId, keyword)}
        .???{ issue -> githubService.addComment(issue.issueId, comment)}

來分析一下每一行的型別分別是什麼,第一行很簡單是 Reader<GithubRepository, User> ,第二行是一個 higher order function ,裡面只有一個參數,型別是 function type,而這個 function 呢,輸入是 User ,輸出是 Reader<GithubRepository, Issue> ,所以這個 function type 的型別是 (User) -> Reader<GithubRepository, Issue> ,依此類推,第三行的 function type 的型別是 (Issue) -> Reader<GithubRepository, Boolean> ,所以答案已經很明顯了, flatMap 剛好就是一個 higher order function ,唯一的參數還是一個輸入 T 配上 Reader 的 Function Type , ??? 要填入的正是 flatMap 。

val result: Reader<GithubRepository, Boolean> = githubService.login(email)
        .flatMap{ user -> githubService.searchIssue(user.userId, keyword)}
        .flatMap{ issue -> githubService.addComment(issue.issueId, comment)}

// When we have githubReposiotry...
val boolean: Boolean = result.run(githubRepository)

小結

跟往常不一樣的,今天介紹了一個用 "function" 當做“容器” 的 Reader ,正因為有了這個 function ,才使得我們有能力延後做 Dependency Injection ,不用在一開始就提供。同時今天的範例也強調了一個重點,“Functional programming 應該要讓流程完整呈現,不應該讓其他的工程師看不懂”。

但是實際情況還是複雜很多,像今天的範例就把 Maybe 跟 List 拿掉了,萬一我真的需要他們怎麼辦?別擔心,在後面的章節會看到的。這邊先提前爆雷一下,像是 List 就可以這樣做:

interface GithubService {
    fun login(email: String): Reader<GithubRepository, User>
    fun searchIssue(userId: String, keyword: String): Reader<GithubRepository, List<Issue>>
    fun addComment(issueId: String, comment: String): Reader<GithubRepository, Boolean>
}

val keyword = "master"
val comment = "No more master/slave"

fun robot() {
	val addCommentReader = githubService.login("yanbin@hotmail.com")
	    .flatMap { user -> githubService.searchIssue(user.userId, keyword) }
	    .flatMap { issues: List<Issue> ->
	        issues.map { issue ->
	            githubService.addComment(issue.issueId, comment)
	        }.liftReader()
	    }
	
	val result: List<Boolean> = addCommentReader.run(githubRepository)
}

fun <D, A> List<Reader<D, A>>.liftReader(): Reader<D, List<A>> {
    ...
}

在 searchIssue 之後回傳的型別是 Reader<GithubRepository, List<Issue>> 。但是在下一行中,如果對 issues 使用 map 卻會得到 List<Reader<GithubRepository, Boolean>> ,這不是我們喜歡看到的型別, 如果是 Reader 在前面會好處理很多。所以 liftReader 就將容器的順序調換過來了!如此一來就可以專心的做 Dependency Injection 而不用擔心 List 。

還有,這邊故意不實作應該知道我的用意了...沒錯!這邊的實作留給大家來挑戰看看!

References:

https://jorgecastillo.dev/kotlin-dependency-injection-with-the-reader-monad


上一篇
Lenses
下一篇
Composition, Abstraction and Principles
系列文
Functional Programming in Kotlin 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言